const { InvalidArgumentError } = require('./error.js');

// @ts-check

class Option {
    /**
     * Initialize a new `Option` with the given `flags` and `description`.
     *
     * @param {string} flags
     * @param {string} [description]
     */

    constructor(flags, description) {
        this.flags = flags;
        this.description = description || '';

        this.required = flags.includes('<'); // A value must be supplied when the option is specified.
        this.optional = flags.includes('['); // A value is optional when the option is specified.
        // variadic test ignores <value,...> et al which might be used to describe custom splitting of single argument
        this.variadic = /\w\.\.\.[>\]]$/.test(flags); // The option can take multiple values.
        this.mandatory = false; // The option must have a value after parsing, which usually means it must be specified on command line.
        const optionFlags = splitOptionFlags(flags);
        this.short = optionFlags.shortFlag;
        this.long = optionFlags.longFlag;
        this.negate = false;
        if (this.long) {
            this.negate = this.long.startsWith('--no-');
        }
        this.defaultValue = undefined;
        this.defaultValueDescription = undefined;
        this.presetArg = undefined;
        this.envVar = undefined;
        this.parseArg = undefined;
        this.hidden = false;
        this.argChoices = undefined;
        this.conflictsWith = [];
        this.implied = undefined;
    }

    /**
     * Set the default value, and optionally supply the description to be displayed in the help.
     *
     * @param {any} value
     * @param {string} [description]
     * @return {Option}
     */

    default(value, description) {
        this.defaultValue = value;
        this.defaultValueDescription = description;
        return this;
    }

    /**
     * Preset to use when option used without option-argument, especially optional but also boolean and negated.
     * The custom processing (parseArg) is called.
     *
     * @example
     * new Option('--color').default('GREYSCALE').preset('RGB');
     * new Option('--donate [amount]').preset('20').argParser(parseFloat);
     *
     * @param {any} arg
     * @return {Option}
     */

    preset(arg) {
        this.presetArg = arg;
        return this;
    }

    /**
     * Add option name(s) that conflict with this option.
     * An error will be displayed if conflicting options are found during parsing.
     *
     * @example
     * new Option('--rgb').conflicts('cmyk');
     * new Option('--js').conflicts(['ts', 'jsx']);
     *
     * @param {string | string[]} names
     * @return {Option}
     */

    conflicts(names) {
        this.conflictsWith = this.conflictsWith.concat(names);
        return this;
    }

    /**
     * Specify implied option values for when this option is set and the implied options are not.
     *
     * The custom processing (parseArg) is not called on the implied values.
     *
     * @example
     * program
     *   .addOption(new Option('--log', 'write logging information to file'))
     *   .addOption(new Option('--trace', 'log extra details').implies({ log: 'trace.txt' }));
     *
     * @param {Object} impliedOptionValues
     * @return {Option}
     */
    implies(impliedOptionValues) {
        let newImplied = impliedOptionValues;
        if (typeof impliedOptionValues === 'string') {
            // string is not documented, but easy mistake and we can do what user probably intended.
            newImplied = { [impliedOptionValues]: true };
        }
        this.implied = Object.assign(this.implied || {}, newImplied);
        return this;
    }

    /**
     * Set environment variable to check for option value.
     *
     * An environment variable is only used if when processed the current option value is
     * undefined, or the source of the current value is 'default' or 'config' or 'env'.
     *
     * @param {string} name
     * @return {Option}
     */

    env(name) {
        this.envVar = name;
        return this;
    }

    /**
     * Set the custom handler for processing CLI option arguments into option values.
     *
     * @param {Function} [fn]
     * @return {Option}
     */

    argParser(fn) {
        this.parseArg = fn;
        return this;
    }

    /**
     * Whether the option is mandatory and must have a value after parsing.
     *
     * @param {boolean} [mandatory=true]
     * @return {Option}
     */

    makeOptionMandatory(mandatory = true) {
        this.mandatory = !!mandatory;
        return this;
    }

    /**
     * Hide option in help.
     *
     * @param {boolean} [hide=true]
     * @return {Option}
     */

    hideHelp(hide = true) {
        this.hidden = !!hide;
        return this;
    }

    /**
     * @api private
     */

    _concatValue(value, previous) {
        if (previous === this.defaultValue || !Array.isArray(previous)) {
            return [value];
        }

        return previous.concat(value);
    }

    /**
     * Only allow option value to be one of choices.
     *
     * @param {string[]} values
     * @return {Option}
     */

    choices(values) {
        this.argChoices = values.slice();
        this.parseArg = (arg, previous) => {
            if (!this.argChoices.includes(arg)) {
                throw new InvalidArgumentError(`允许选择的是 ${this.argChoices.join(', ')}.`);
            }
            if (this.variadic) {
                return this._concatValue(arg, previous);
            }
            return arg;
        };
        return this;
    }

    /**
     * Return option name.
     *
     * @return {string}
     */

    name() {
        if (this.long) {
            return this.long.replace(/^--/, '');
        }
        return this.short.replace(/^-/, '');
    }

    /**
     * Return option name, in a camelcase format that can be used
     * as a object attribute key.
     *
     * @return {string}
     * @api private
     */

    attributeName() {
        return camelcase(this.name().replace(/^no-/, ''));
    }

    /**
     * Check if `arg` matches the short or long flag.
     *
     * @param {string} arg
     * @return {boolean}
     * @api private
     */

    is(arg) {
        return this.short === arg || this.long === arg;
    }

    /**
     * Return whether a boolean option.
     *
     * Options are one of boolean, negated, required argument, or optional argument.
     *
     * @return {boolean}
     * @api private
     */

    isBoolean() {
        return !this.required && !this.optional && !this.negate;
    }
}

/**
 * This class is to make it easier to work with dual options, without changing the existing
 * implementation. We support separate dual options for separate positive and negative options,
 * like `--build` and `--no-build`, which share a single option value. This works nicely for some
 * use cases, but is tricky for others where we want separate behaviours despite
 * the single shared option value.
 */
class DualOptions {
    /**
     * @param {Option[]} options
     */
    constructor(options) {
        this.positiveOptions = new Map();
        this.negativeOptions = new Map();
        this.dualOptions = new Set();
        options.forEach((option) => {
            if (option.negate) {
                this.negativeOptions.set(option.attributeName(), option);
            } else {
                this.positiveOptions.set(option.attributeName(), option);
            }
        });
        this.negativeOptions.forEach((value, key) => {
            if (this.positiveOptions.has(key)) {
                this.dualOptions.add(key);
            }
        });
    }

    /**
     * Did the value come from the option, and not from possible matching dual option?
     *
     * @param {any} value
     * @param {Option} option
     * @returns {boolean}
     */
    valueFromOption(value, option) {
        const optionKey = option.attributeName();
        if (!this.dualOptions.has(optionKey)) return true;

        // Use the value to deduce if (probably) came from the option.
        const preset = this.negativeOptions.get(optionKey).presetArg;
        const negativeValue = preset !== undefined ? preset : false;
        return option.negate === (negativeValue === value);
    }
}

/**
 * Convert string from kebab-case to camelCase.
 *
 * @param {string} str
 * @return {string}
 * @api private
 */

function camelcase(str) {
    return str.split('-').reduce((str, word) => {
        return str + word[0].toUpperCase() + word.slice(1);
    });
}

/**
 * Split the short and long flag out of something like '-m,--mixed <value>'
 *
 * @api private
 */

function splitOptionFlags(flags) {
    let shortFlag;
    let longFlag;
    // Use original very loose parsing to maintain backwards compatibility for now,
    // which allowed for example unintended `-sw, --short-word` [sic].
    const flagParts = flags.split(/[ |,]+/);
    if (flagParts.length > 1 && !/^[[<]/.test(flagParts[1])) shortFlag = flagParts.shift();
    longFlag = flagParts.shift();
    // Add support for lone short flag without significantly changing parsing!
    if (!shortFlag && /^-[^-]$/.test(longFlag)) {
        shortFlag = longFlag;
        longFlag = undefined;
    }
    return { shortFlag, longFlag };
}

exports.Option = Option;
exports.splitOptionFlags = splitOptionFlags;
exports.DualOptions = DualOptions;
